feat: one HarnessClient, one daemon, one MCP path - #974
Conversation
statusCommandRpc was simultaneously the Unix-socket RPC and the schema source for two surviving consumers: the MCP status tool and the harness client's identity read. That made the plane which outlives the socket depend on the plane which does not. The harness plane now states the status contract itself and exposes it as JSON Schemas, derived types, and a strict decoder. The socket surface states the same shape independently, because a schema constructor cannot cross the boundary; drift is still a compile error either way, since each side implements a handler against its own schema. Dropping the RPC handler context also removes the Rpc.isWrapper unwrap and its accompanying cast from the status tool registration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P76aaa1STr3WPZ3nDascta
A profile record was exactly {agentId, apiKey, agentName}, so nothing
could tell a daemon which port to bind and moltzapd required a
caller-supplied --port. No production code could compute an MCP URL.
The record becomes a slot: agentName and a required mcpPort, plus the
identity pair agentId and apiKey, which are written together at Registry
commit or absent together before it. moltzapd resolves its own port from
the slot it was named with, so --port is gone from the published binary.
loadServiceConfig now distinguishes an absent slot from a slot with no
committed identity, which the registration surface will branch on.
Strict decode has no compatible intermediate: a record either carries
mcpPort or fails to decode. Every writer moves in this commit, and the
simulator and packaged-daemon fixtures choose a port before writing the
slot rather than handing one to the daemon.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P76aaa1STr3WPZ3nDascta
Nothing in production could build a HarnessClient: the only code that started a daemon and connected to it lived in test-utils, and it demanded a caller-supplied port and an injected checkpoint store. harnessClientForProfile takes a profile name and nothing else. It starts the slot's own daemon child, derives the loopback endpoint from the same persisted port the daemon binds, and provides a file-backed checkpoint store. The checkpoint directory is keyed by profile name because the store must be provided before the client reads its identity from the daemon's status tool, and one slot is exactly one AgentId. The daemon supervisor moves out of test-utils to the source root; only the port reservation a test performs before writing a slot stays behind. The packaged round-trip test now drives the production composition instead of a parallel test-only path with an in-memory store. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P76aaa1STr3WPZ3nDascta
The plugin chose between a HarnessClient and a directly constructed MoltZapService at runtime, and only the second arm was reachable in production, because nothing could compute a daemon endpoint. The slot now carries its port, so the account id alone resolves a client. startAccount acquires that client unconditionally and drains it. The legacy arm goes with everything that existed to serve it: the gateway service and core constructors, the inbound bridge that synthesized a turn from a raw core message, connection-status reporting, the outbound dispatcher, the active-service map, and the legacy half of the gateway lifecycle. The directory section goes with it. Serving listPeers and listGroups through the client would publish agent and conversation search on the adapter-facing surface, which the accepted HarnessClient record keeps as MCP management operations. A harness-backed account already returned empty lists, so this makes an existing silent gap explicit. Proactive sends into an existing conversation and MoltZapService.sendToAgent go too. Repeated proactive DMs to one agent no longer share a conversation; that regression is tracked separately. The docker suites and their container harness are deleted rather than carried: they were gated behind an image that has no Dockerfile here and no CI step builds one, so they had never executed. No integration suite remained in the package afterward, so its setup and config go as well. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P76aaa1STr3WPZ3nDascta
The adapter carried two constructors and production only ever reached the one that built MoltZapService and MoltZapChannelCore directly, because nothing could resolve a daemon endpoint. The slot now carries its port, so the profile name alone resolves a client. NanoClaw registers its channel through a zero-argument factory that runs at module import, so there is no seam to inject a client through. The adapter therefore owns the scope itself: it acquires in setup and releases in teardown. That inverts the borrowed-client contract, so the borrowing constructor goes rather than surviving beside it. The simulator writes the slot and stops there. It does not start a daemon: the adapter starts its own, and a real nanoclaw checkout has no simulator to start one for it. Two daemons would bind the same slot's single port. The channel stays one file. The asset copier installs exactly this module into a downloaded checkout, so acquisition lives here or behind a client export, never in a sibling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P76aaa1STr3WPZ3nDascta
…lane The daemon bound its listener only after loadServiceConfig succeeded, so a slot without a committed identity had nowhere to register and the registration catalog stayed empty. The listener now binds first and its catalog follows slot state: register and status before commit, the six active tools after. The official SDK builds a fresh server per request, so the transition is a phase read rather than a rebind — the URL never changes and an open client sees the new catalog on its next tools/list. /register/mcp goes with it. main's accepted 20260728-endpoint-daemon-speaks- modern-mcp fixes host and path for one loopback /mcp; the two-path shape is admitted only on v2, whose records govern v2/* and not packages/*. Registration was the CLI's last unique capability, so the whole `moltzap` command tree, the Unix socket plane it spoke to, and the local history surface that existed only to serve that socket all go. `moltzapd` is now the package's only bin. The socket-absence assertions in the daemon integration tests go too: with nothing left that could bind a socket, they assert nothing. scripts/quickstart.sh wrote the pre-slot three-field profile shape, which strict decode has rejected since mcpPort became required. It now writes a port per slot and starts daemons instead of CLI commands. docs/spec/cli.md and docs/spec/endpoints/daemon.md are main-resident v2 content that v2 itself has already deleted. Both gain a scope note rather than a deletion, so a reader cannot mistake them for a production contract.
The server counts a closed socket asynchronously, so asserting the count the instant a scope closes is a race. It passed before only because `beginShutdown` also stopped the Unix socket server, and that work bought enough latency to hide it; deleting the socket plane removed the latency. This file already carries `waitForConnectionCount` for exactly this reason and its sibling process test already uses it. The teardown assertions now do too.
…bin map Only "clean-slate cannot import production" was enforced. Three rules now hold the shape the cutover produced: adapters may reach @moltzap/client only through its published adapter-facing subpaths, no source may import daemon-side machinery by name, and the client's export and bin maps are pinned. The subpath rule alone was insufficient — channel-base re-exported MoltZapChannelCore and ChannelService, so any adapter could have taken the long way round. That barrel now exports the seven presentation names its consumers actually use; both classes have no consumer outside packages/client. The rules scan shipped sources only. Test scaffolding legitimately drives a peer agent and registers fixtures, and none of it reaches a user. The three client-side conformance configs matched no tests and ran passWithNoTests, so four CI steps reported green for nothing. Deleted rather than left standing in for an unmet criterion; the deferral is recorded in #926.
The socket support module went with the Unix RPC plane; its JSDoc header outlived it and now labels the wrong export.
`20260801-harness-client-owns-runtime-context` states the client "stores stable per-conversation presentation checkpoints locally. After restart it uses search and history reads to rebuild context from those positions", "presents context at most once during normal operation", and that "history reads rebuild context only and never recreate reply authority". Nothing tested any of it. Three client lifetimes against one slot. Each spawns its own daemon; only the checkpoint directory survives between them. The second lifetime must not re-present the content the first already carried, and its bound reply must still land in the live conversation. Deleting the checkpoint directory restores the full re-read, so the narrowing is the checkpoints and not a recency window. A turn's reply is bound to the MCP client that produced it, so the reply round trip runs inside the lifetime that owns that client rather than escaping its scope.
# Conflicts: # .github/workflows/ci.yml # package.json
Main renamed packages/simulator/src/runtime/ to src/agents/ and moved agent runtimes into containers, so the four files this branch edited there are gone. The branch reserved a loopback port on the simulator host with reserveSlotMcpPort(). That contradicts the contract both sides already document: profile.ts says mcpPort is "operator-supplied and stable ... nothing discovers, allocates, or falls back", and this branch's own CHANGELOG entry says the port "is yours to choose". Host-side reservation is also meaningless once each agent owns a network namespace, and wrong on the GKE path, where the container does not run on the simulator's host at all. So the port is a constant: SLOT_MCP_CONTAINER_PORT sits beside the gateway ports the same container binds, and serializeMoltZapProfileConfig now emits mcpPort at both container call sites. That also closes a gap on main, where profileRecordSchema required mcpPort but nothing in production supplied it. Allocation stays a test concern, where reserveTestMcpPort already lives. check-adr-shape compared a record only against the first parent, so a merge that adopts the other side's already-landed record read as an unexplained rewrite. It now recognizes content identical to the incoming parent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
Post-commit activation retry decision source eventThis comment is agent-authored under the maintainer account. It durably transcribes the public decision exchange that governs the registration recovery correction in this pull request. It is evidence, not normative authority. Source gap. The current Codex API surface did not expose a native session identifier, message identifier, parent locator, or stored event timestamp to the agent. The GitHub comment timestamp is the time of this mechanical transcription, not a reconstructed source timestamp. The named decision-maker must review this linkage when accepting the ADR and blind-review result. Agent prompt, current Codex workspace session, stored role
Human reply, current Codex workspace session, stored role
No rationale, motive, confidence, or urgency is inferred from the reply. |
PR #974 is the production bridge into the v2 cutover: one adapter contract, one daemon, one loopback MCP path, and no retained v1 CLI/socket/send route.
Outcome
HarnessClientis the sole runtime-adapter capability. OpenClaw and NanoClaw no longer constructMoltZapService,MoltZapChannelCore, transports, or daemon lifecycle directly.moltzapdowns one profile slot and one fixed/mcpendpoint. Before identity commit the catalog isregister+status; afterward it isstatus,search_agents,search_conversations,read_conversation,start_conversation, andreply. Receive remains the MCP subscription, not another tool.{ agentName, mcpPort, agentId?, apiKey? }.moltzapCLI, Unix socket, local RPC dialect, and generic adapter send are removed.messages.seqis an always-generated PostgreSQLBIGINTidentity; same-conversation inserts serialize on the conversation row so checkpoints cannot skip a still-pending lower commit.Landing hardening
The refreshed branch includes the latest
mainand closes the landing findings found during the altitude/readability pass:Breaking
~/.moltzap/config.jsonprofiles fail strict decode. AddagentNameand a stable nonzeromcpPort; there is no pre-launch migration shim.@moltzap/clientships only themoltzapdbinary.Decisions and review
Main-owned accepted ADRs:
Final semantic candidate:
8f70a1e47558e95dbfe957e563a59099b81afe282297c39b33663521167b68fb38927df195a0ef2b7b83bf924871b5e2a591939e2d9dcb5b9e3fd8290c59c275d24add29f496488aThe seventh fresh isolated teammate review passed all six fixed questions with no blockers: review record. Every earlier failed or invalid attempt is retained alongside it for auditability.
Maintainer acceptance of the passing review: PENDING. Reviewer prose is not self-certifying; do not merge until that acceptance is recorded.
Verification
Local verification is green:
GitHub CI for this pushed revision is pending.
Deliberately tracked outside this v1 landing
The last three are inputs to the four-layer cutover, not reasons to expand the retiring v1 stack.
Superseded stack
This PR contains the production work formerly split across #943, #944, #945, #946, #947, #948, #950, #951, #953, #954, #955, #959, #960, #961, #962, and #972. Their histories remain readable.